/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The SF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ package org.apache.sling.discovery.base.connectors.ping; import java.io.IOException; import java.io.StringReader; import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.security.AlgorithmParameters; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.Key; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; import java.security.spec.InvalidParameterSpecException; import java.security.spec.KeySpec; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.zip.GZIPInputStream; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; import javax.crypto.IllegalBlockSizeException; import javax.crypto.Mac; import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import javax.json.Json; import javax.json.JsonArray; import javax.json.JsonArrayBuilder; import javax.json.JsonException; import javax.json.JsonObject; import javax.json.JsonObjectBuilder; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.codec.binary.Base64; import org.apache.commons.io.IOUtils; import org.apache.http.Header; import org.apache.http.HttpResponse; import org.apache.http.client.methods.HttpUriRequest; import org.apache.sling.discovery.base.connectors.BaseConfig; /** * Request Validator helper. */ public class TopologyRequestValidator { public static final String SIG_HEADER = "X-SlingTopologyTrust"; public static final String HASH_HEADER = "X-SlingTopologyHash"; /** * Maximum number of keys to keep in memory. */ private static final int MAXKEYS = 5; /** * Minimum number of keys to keep in memory. */ private static final int MINKEYS = 3; /** * true if trust information should be in request headers. */ private boolean trustEnabled; /** * true if encryption of the message payload should be encrypted. */ private boolean encryptionEnabled; /** * map of hmac keys, keyed by key number. */ private Map<Integer, Key> keys = new ConcurrentHashMap<Integer, Key>(); /** * The shared key. */ private String sharedKey; /** * TTL of each shared key generation. */ private long interval; /** * If true, everything is deactivated. */ private boolean deactivated; private SecureRandom random = new SecureRandom(); /** * Create a TopologyRequestValidator. * * @param config the configuation object */ public TopologyRequestValidator(BaseConfig config) { trustEnabled = false; encryptionEnabled = false; if (config.isHmacEnabled()) { trustEnabled = true; sharedKey = config.getSharedKey(); interval = config.getKeyInterval(); encryptionEnabled = config.isEncryptionEnabled(); } deactivated = false; } /** * Encodes a request returning the encoded body * * @param body * @return the encoded body. * @throws IOException */ public String encodeMessage(String body) throws IOException { checkActive(); if (encryptionEnabled) { try { JsonObjectBuilder json = Json.createObjectBuilder(); JsonArrayBuilder array = Json.createArrayBuilder(); for (String value : encrypt(body)) { array.add(value); } json.add("payload", array); StringWriter writer = new StringWriter(); Json.createGenerator(writer).write(json.build()).close(); return writer.toString(); } catch (InvalidKeyException e) { e.printStackTrace(); throw new IOException("Unable to Encrypt Message " + e.getMessage()); } catch (IllegalBlockSizeException e) { throw new IOException("Unable to Encrypt Message " + e.getMessage()); } catch (BadPaddingException e) { throw new IOException("Unable to Encrypt Message " + e.getMessage()); } catch (UnsupportedEncodingException e) { throw new IOException("Unable to Encrypt Message " + e.getMessage()); } catch (NoSuchAlgorithmException e) { throw new IOException("Unable to Encrypt Message " + e.getMessage()); } catch (NoSuchPaddingException e) { throw new IOException("Unable to Encrypt Message " + e.getMessage()); } catch (JsonException e) { throw new IOException("Unable to Encrypt Message " + e.getMessage()); } catch (InvalidKeySpecException e) { throw new IOException("Unable to Encrypt Message " + e.getMessage()); } catch (InvalidParameterSpecException e) { throw new IOException("Unable to Encrypt Message " + e.getMessage()); } } return body; } /** * Decode a message sent from the client. * * @param request the request object for the message. * @return the message in clear text. * @throws IOException if there is a problem decoding the message or the * message is invalid. */ public String decodeMessage(HttpServletRequest request) throws IOException { checkActive(); return decodeMessage("request:", request.getRequestURI(), getRequestBody(request), request.getHeader(HASH_HEADER)); } /** * Decode a response from the server. * * @param response the response. * @return the message in clear text. * @throws IOException if there was a problem decoding the message. */ public String decodeMessage(String uri, HttpResponse response) throws IOException { checkActive(); return decodeMessage("response:", uri, getResponseBody(response), getResponseHeader(response, HASH_HEADER)); } /** * Decode a message * * @param prefix the prefix to indicate if the message is a request or * response message. * @param url the url associated with the message. * @param body the body of the message. * @param requestHash a hash of the message. * @return the message in clear text * @throws IOException if the message can't be decrypted. */ private String decodeMessage(String prefix, String url, String body, String requestHash) throws IOException { if (trustEnabled) { String bodyHash = hash(prefix + url + ":" + body); if (bodyHash.equals(requestHash)) { if (encryptionEnabled) { try { JsonObject json = Json.createReader(new StringReader(body)).readObject(); if (json.containsKey("payload")) { return decrypt(json.getJsonArray("payload")); } } catch (JsonException e) { throw new IOException("Encrypted Message is in the correct json format"); } catch (InvalidKeyException e) { throw new IOException("Encrypted Message is in the correct json format"); } catch (IllegalBlockSizeException e) { throw new IOException("Encrypted Message is in the correct json format"); } catch (BadPaddingException e) { throw new IOException("Encrypted Message is in the correct json format"); } catch (NoSuchAlgorithmException e) { throw new IOException("Encrypted Message is in the correct json format"); } catch (NoSuchPaddingException e) { throw new IOException("Encrypted Message is in the correct json format"); } catch (InvalidAlgorithmParameterException e) { throw new IOException("Encrypted Message is in the correct json format"); } catch (InvalidKeySpecException e) { throw new IOException("Encrypted Message is in the correct json format"); } } } throw new IOException("Message is not valid, hash does not match message"); } return body; } /** * Is the request from the client trusted, based on the signature headers. * * @param request the request. * @return true if trusted, or true if this component is disabled. */ public boolean isTrusted(HttpServletRequest request) { checkActive(); if (trustEnabled) { return checkTrustHeader(request.getHeader(HASH_HEADER), request.getHeader(SIG_HEADER)); } return false; } /** * Is the response from the server to be trusted by the client. * * @param response the response * @return true if trusted, or true if this component is disabled. */ public boolean isTrusted(HttpResponse response) { checkActive(); if (trustEnabled) { return checkTrustHeader(getResponseHeader(response, HASH_HEADER), getResponseHeader(response, SIG_HEADER)); } return false; } /** * Trust a message on the client before sending, only if trust is enabled. * * @param method the method which will have headers set after the call. * @param body the body. */ public void trustMessage(HttpUriRequest method, String body) { checkActive(); if (trustEnabled) { String bodyHash = hash("request:" + method.getURI().getPath() + ":" + body); method.setHeader(HASH_HEADER, bodyHash); method.setHeader(SIG_HEADER, createTrustHeader(bodyHash)); } } /** * Trust a response message sent from the server to the client. * * @param response the response. * @param request the request, * @param body body of the response. */ public void trustMessage(HttpServletResponse response, HttpServletRequest request, String body) { checkActive(); if (trustEnabled) { String bodyHash = hash("response:" + request.getRequestURI() + ":" + body); response.setHeader(HASH_HEADER, bodyHash); response.setHeader(SIG_HEADER, createTrustHeader(bodyHash)); } } /** * @param body * @return a hash of body base64 encoded. */ private String hash(String toHash) { try { MessageDigest m = MessageDigest.getInstance("SHA-256"); return new String(Base64.encodeBase64(m.digest(toHash.getBytes("UTF-8"))), "UTF-8"); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e.getMessage(), e); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e.getMessage(), e); } } /** * Generate a signature of the bodyHash and encode it so that it contains * the key number used to generate the signature. * * @param bodyHash a hash * @return the signature. */ private String createTrustHeader(String bodyHash) { try { int keyNo = getCurrentKey(); return keyNo + "/" + hmac(keyNo, bodyHash); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e.getMessage(), e); } catch (InvalidKeyException e) { throw new RuntimeException(e.getMessage(), e); } catch (IllegalStateException e) { throw new RuntimeException(e.getMessage(), e); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e.getMessage(), e); } } /** * Check that the signature is a signature of the body hash. * * @param bodyHash the body hash. * @param signature the signature. * @return true if the signature can be trusted. */ private boolean checkTrustHeader(String bodyHash, String signature) { try { if (bodyHash == null || signature == null ) { return false; } String[] parts = signature.split("/", 2); int keyNo = Integer.parseInt(parts[0]); return hmac(keyNo, bodyHash).equals(parts[1]); } catch (ArrayIndexOutOfBoundsException e) { return false; } catch (IllegalArgumentException e) { return false; } catch (InvalidKeyException e) { throw new RuntimeException(e.getMessage(), e); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e.getMessage(), e); } catch (IllegalStateException e) { throw new RuntimeException(e.getMessage(), e); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e.getMessage(), e); } catch (Exception e) { throw new RuntimeException(e.getMessage(), e); } } /** * Get a Mac instance for the key number. * * @param keyNo the key number. * @return the mac instance. * @throws NoSuchAlgorithmException * @throws InvalidKeyException * @throws UnsupportedEncodingException */ private Mac getMac(int keyNo) throws NoSuchAlgorithmException, InvalidKeyException, UnsupportedEncodingException { Mac m = Mac.getInstance("HmacSHA256"); m.init(getKey(keyNo)); return m; } /** * Perform a HMAC on the body using the key specified. * * @param keyNo the key number. * @param bodyHash a hash of the body. * @return the hmac signature. * @throws InvalidKeyException * @throws UnsupportedEncodingException * @throws IllegalStateException * @throws NoSuchAlgorithmException */ private String hmac(int keyNo, String bodyHash) throws InvalidKeyException, UnsupportedEncodingException, IllegalStateException, NoSuchAlgorithmException { return new String(Base64.encodeBase64(getMac(keyNo).doFinal(bodyHash.getBytes("UTF-8"))), "UTF-8"); } /** * Decrypt the body. * * @param jsonArray the encrypted payload * @return the decrypted payload. * @throws IllegalBlockSizeException * @throws BadPaddingException * @throws UnsupportedEncodingException * @throws InvalidKeyException * @throws NoSuchAlgorithmException * @throws NoSuchPaddingException * @throws InvalidKeySpecException * @throws InvalidAlgorithmParameterException * @throws JSONException */ private String decrypt(JsonArray jsonArray) throws IllegalBlockSizeException, BadPaddingException, UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidAlgorithmParameterException, InvalidKeySpecException { Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, getCiperKey(Base64.decodeBase64(jsonArray.getString(0).getBytes("UTF-8"))), new IvParameterSpec(Base64.decodeBase64(jsonArray.getString(1).getBytes("UTF-8")))); return new String(cipher.doFinal(Base64.decodeBase64(jsonArray.getString(2).getBytes("UTF-8")))); } /** * Encrypt a payload with the numbed key/ * * @param payload the payload. * @param keyNo the key number. * @return an encrypted version. * @throws IllegalBlockSizeException * @throws BadPaddingException * @throws UnsupportedEncodingException * @throws InvalidKeyException * @throws NoSuchAlgorithmException * @throws NoSuchPaddingException * @throws InvalidKeySpecException * @throws InvalidParameterSpecException */ private List<String> encrypt(String payload) throws IllegalBlockSizeException, BadPaddingException, UnsupportedEncodingException, InvalidKeyException, NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeySpecException, InvalidParameterSpecException { Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); byte[] salt = new byte[9]; random.nextBytes(salt); cipher.init(Cipher.ENCRYPT_MODE, getCiperKey(salt)); AlgorithmParameters params = cipher.getParameters(); List<String> encrypted = new ArrayList<String>(); encrypted.add(new String(Base64.encodeBase64(salt))); encrypted.add(new String(Base64.encodeBase64(params.getParameterSpec(IvParameterSpec.class).getIV()))); encrypted.add(new String(Base64.encodeBase64(cipher.doFinal(payload.getBytes("UTF-8"))))); return encrypted; } /** * @param salt number of the key. * @return the CupherKey. * @throws UnsupportedEncodingException * @throws NoSuchAlgorithmException * @throws InvalidKeySpecException */ private Key getCiperKey(byte[] salt) throws UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeySpecException { SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); // hashing the password 65K times takes 151ms, hashing 256 times takes 2ms. // Since the salt has 2^^72 values, 256 times is probably good enough. KeySpec spec = new PBEKeySpec(sharedKey.toCharArray(), salt, 256, 128); SecretKey tmp = factory.generateSecret(spec); SecretKey key = new SecretKeySpec(tmp.getEncoded(), "AES"); return key; } /** * @param keyNo number of the key. * @return the HMac key. * @throws UnsupportedEncodingException */ private Key getKey(int keyNo) throws UnsupportedEncodingException { if(Math.abs(keyNo - getCurrentKey()) > 1 ) { throw new IllegalArgumentException("Key has expired"); } if (keys.containsKey(keyNo)) { return keys.get(keyNo); } trimKeys(); SecretKeySpec key = new SecretKeySpec(hash(sharedKey + keyNo).getBytes("UTF-8"), "HmacSHA256"); keys.put(keyNo, key); return key; } private int getCurrentKey() { return (int) (System.currentTimeMillis() / interval); } /** * dump olf keys. */ private void trimKeys() { if (keys.size() > MAXKEYS) { List<Integer> keysKeys = new ArrayList<Integer>(keys.keySet()); Collections.sort(keysKeys); for (Integer k : keysKeys) { if (keys.size() < MINKEYS) { break; } keys.remove(k); } } } /** * Get the value of a response header. * * @param response the response * @param name the name of the response header. * @return the value of the response header, null if none. */ private String getResponseHeader(HttpResponse response, String name) { Header h = response.getFirstHeader(name); if (h == null) { return null; } return h.getValue(); } /** * Get the request body. * * @param request the request. * @return the body as a string. * @throws IOException */ private String getRequestBody(HttpServletRequest request) throws IOException { final String contentEncoding = request.getHeader("Content-Encoding"); if (contentEncoding!=null && contentEncoding.contains("gzip")) { // then treat the request body as gzip: final GZIPInputStream gzipIn = new GZIPInputStream(request.getInputStream()); final String gunzippedEncodedJson = IOUtils.toString(gzipIn); gzipIn.close(); return gunzippedEncodedJson; } else { // otherwise assume plain-text: return IOUtils.toString(request.getReader()); } } /** * @param response the response * @return the body of the response from the server. * @throws IOException */ private String getResponseBody(HttpResponse response) throws IOException { final Header contentEncoding = response.getFirstHeader("Content-Encoding"); if (contentEncoding!=null && contentEncoding.getValue()!=null && contentEncoding.getValue().contains("gzip")) { // then the server sent gzip - treat it so: final GZIPInputStream gzipIn = new GZIPInputStream(response.getEntity().getContent()); final String gunzippedEncodedJson = IOUtils.toString(gzipIn); gzipIn.close(); return gunzippedEncodedJson; } else { // otherwise the server sent plaintext: return IOUtils.toString(response.getEntity().getContent(), "UTF-8"); } } /** * throw an exception if not active. */ private void checkActive() { if (deactivated) { throw new IllegalStateException(this.getClass().getName() + " is not active"); } if ((trustEnabled || encryptionEnabled) && sharedKey == null) { throw new IllegalStateException(this.getClass().getName() + " Shared Key must be set if encryption or signing is enabled."); } } }